Conversation
- Made mobile toolbar no longer experimental & part of default UI - Updated example
- Removed hover styles for mobile - Gated comment and link buttons to only show when selection is not empty
…p or mobile version should be used
…with external keyboard
…ple (#2985) * docs: name the mobile toolbar layouts, add layout toggle to example Introduce "scrolling document" (default) vs "pinned scroll container" (opt-in) as the names for the two page layouts the mobile formatting toolbar supports, and restructure the docs section around them. - Docs: simple-first rewrite of the Mobile Formatting Toolbar section (default layout, then the opt-in layout with its two CSS rules). - Example: stop embedding it in the docs (`docs: false`) - its page-level CSS (html/body overflow, full-viewport fixed scroll host, `.prose` rules) leaks into the docs page since examples render inline. Link to the standalone playground example instead. - Example: add a nav-bar switch that toggles the pinned scroll container layout via a class on <html>, so both layouts can be compared. - Playground: `.mantine-AppShell-root` width 100vw -> 100%, which caused a horizontal scrollbar on any example taller than the viewport. - Align README, JSDoc and example comments with the new naming; regenerate examples.gen.tsx. * Implemented PR feedback --------- Co-authored-by: Matthew Lipski <matthewlipski@gmail.com>
`useEditorFocusChange` only offered a callback, so anything that wanted to *render* based on focus had to wire up useState + useEffect itself — which MobileFormattingToolbarController did, including a manual re-sync for focus that changed before the subscription attached. `useEditorFocus(options?, editor?)` returns that as state. The two hooks now mirror the split the codebase already has between `useEditorState` and `useEditorChange`: state for rendering, callback for side effects. Built on useSyncExternalStore, so there's no extra render on mount and no tearing. The snapshot is deliberately the last *settled* value rather than a live `isFocused()` read: reading focus during an arbitrary render can catch a mid-handoff frame where document.activeElement is transiently <body> and the editor looks unfocused. The controller now reads as one line, and no longer needs the editor instance at all. Covered by browser tests for the behaviours that justify the hook: content focus/blur, staying focused across a handoff into portalled UI, not re-rendering for unrelated focus changes elsewhere on the page (these are document-level listeners, so it sees them all), and not re-rendering while typing.
Adds a regression test for the latest-ref pattern in `useEditorFocusChange`: an inline callback (a new identity every render) must not cause the editor subscription to be torn down and re-attached. Measured both ways before writing it — the naive implementation, with the callback in the dependency array, resubscribes once per render (6 after 5 re-renders); the latest-ref version stays at 1. That churn is worse than it looks with `includeEditorUI`, where the subscription is reference-counted: each cycle detaches and re-attaches the document focus listeners and resets the settled baseline. Also corrects a comment in useEditorFocus: React re-checks the snapshot *after* subscribing (its subscribe effect is registered before the consistency-check effect — verified in react-dom's source), so the re-sync inside subscribe is what makes the "focus changed between render and subscription" case work.
… for The previous wording said it covers "UI rendered as a sibling of the content" without saying which UI that is — and BlockNote's own default UI is entirely portalled (a mounted editor's container has exactly two children: the content element and the portal), so on its own that justification doesn't hold up. The real case is UI the host app passes as `BlockNoteView` children, which React renders as siblings of the content element. The shipped "Static Formatting Toolbar" example is exactly that: `<FormattingToolbar />` rendered inline rather than through a controller, putting eleven focusable buttons next to the content. Without the hop, focusing one of them reads as "outside the editor", which would dismiss the mobile toolbar mid-interaction and trip the side menu's click-outside check.
Opening a toolbar popover reset the page scroll to the top, taking the
block being edited off screen entirely. Measured on the
mobile-formatting-toolbar example: scrollTop 451 -> 0, putting the
edited editor at y=800 in a 427px viewport.
Cause: the input's native `autofocus` fires while floating-ui has not
positioned the popover yet, so the browser scrolls to the popover's
pre-positioned spot — the top of the container — instead of where it
ends up. Focusing through a ref with `{ preventScroll: true }` keeps
the page still; floating-ui positions the popover regardless, so it
still appears in the right place.
Applied in all three UI packages' form TextInput, since they all take
`autoFocus` for popover inputs (link, file embed, rename, caption).
Covered by a mobile e2e test asserting neither the scroll position nor
the edited editor moves when the popover opens — proven red without the
fix (451 -> 0). Reported from a real device.
Reported from a device: select text in the first of two editors, open the link popover, type a URL, press Enter — no link is created and focus jumps to the *second* editor. Android's IME chooses the Enter key's action itself. With no surrounding <form> and no enterkeyhint, Chrome picks IME_ACTION_NEXT whenever another focusable element follows, and Next advances focus rather than dispatching a key event — so the keydown handler that creates the link never runs. That also explains the asymmetry in the report: from the last editor on the page nothing focusable follows, so Chrome picks Done instead, Enter is dispatched, and the link is created as expected. Setting enterkeyhint="done" on the popover inputs makes the key report itself as Enter everywhere. Applied in all three UI packages, so it covers the link, file embed, rename and caption popovers. The mobile toolbar test now asserts the attribute — a DOM-contract check rather than a behavioural one, since emulation always dispatches a real Enter and so cannot reproduce the IME's action choice.
Replaces the enterkeyhint attribute assertion with a test of what the user actually reported: opening the link popover from the first of two editors, submitting, and expecting the link in *that* editor with focus still there — rather than the link missing and focus in the second one. The attribute assertion stays, but as one line inside that behavioural test rather than as the test itself, because it is the only part of the IME contract a test can hold onto. The device-only half — which action Android assigns to the Enter key — is not reachable from any automated environment we have: emulation always dispatches a real Enter, and on BrowserStack no input channel reaches the on-screen keyboard. That half is now a release-checklist item in the device README instead of being silently uncovered.
Follow-up to the enterkeyhint fix, which had no red-first test: nothing
in an emulated browser can reproduce an IME choosing to advance focus
instead of dispatching Enter, so the only thing a test could assert was
the attribute itself.
The deeper problem is that these popovers had no submission path at all
besides a keydown listener. `Form.Root` rendered a plain `<div>`, so
the `onSubmit` prop the TextInputs already accepted could never fire —
`submit` is dispatched on forms, not inputs. When a platform reports
Enter-to-submit as a form submission rather than a key event, nothing
happened.
`Form.Root` now renders a real `<form>` and takes `onSubmit`, wired up
in all four callers (link, file rename, file caption, AI prompt). It
always preventDefaults, so a caller that doesn't pass a handler can
never navigate away. The dead per-input `onSubmit` props are removed.
This also gives the browser proper form context, which is what it uses
to decide the IME's action key in the first place.
Now testable without any key event: fill the popover, call
`form.requestSubmit()` — exactly what the browser does when the IME
action means submit — and assert the link is created. Proven red with
`Form.Root` back to a `<div>` ("the popover must be a real <form>").
This was referenced Aug 31, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the Android Chrome keyboard/popover collapse at its root, plus the editing and focus bugs found while chasing it, and adds the mobile test infrastructure that keeps them fixed.
Branched off
mobile-toolbar-demo, and supersedes #2982.#2982 approached the collapse from the symptom end —
autoFocus={false}on the link URL input, plus anonClickCapturehandler that scrolls the input into view and restores the editor scroll a second later, with theonClickCaptureprop plumbed throughComponentsContextand all three UI packages'TextInputs. This PR fixes the root cause instead (see below), which makes all of that unnecessary: it costs autofocus on desktop, and adds public API surface for a workaround nothing needs any more.The one part of #2982 that is not superseded — the
pointer: coarserule that keeps.bn-form-popoverinputs at 16px so iOS Safari doesn't auto-zoom on focus — is cherry-picked here with Matthew's authorship intact, and now has the test coverage it previously lacked. So #2982 can be closed once this lands.Fixes
Mantine popover collapse (the original bug). Mantine's
hideDetached(defaulttrue) reacts to the viewport resize the on-screen keyboard causes by settingdisplay: noneon the still-mounted dropdown. Adisplay: noneancestor can't hold focus, so the popover's input blurred, the toolbar unmounted, and the keyboard dismissed — the whole collapse cascade. DisablinghideDetachedfor portalled (mobile) popovers fixes it at the source.Enter on Android (#3001). prosemirror-view ignores Enter keydowns on Android Chrome and falls back to parsing the browser's native DOM split, which misparses BlockNote's nested block DOM and corrupts the document — Enter inserting a space, doing nothing, or breaking tables. BlockNote now intercepts the
beforeinputinstead and dispatches through the keymap chain, restoring thedomObserver.forceFlush()parity prosemirror's Android bail skips. Verified as not a recent regression: the same race exists in every version pairing back to BlockNote 0.29 / prosemirror-view 1.38.1, bisected with era-correct overrides.Public focus API.
editor.isFocused({ includeFloatingUI })andeditor.onFocusChange(cb, { includeFloatingUI })answer "is the user still interacting with this editor" — content focus or focus inside the editor's own floating UI. Replaces reaching intoeditor._tiptapEditor.on("focus"/"blur"), which can't see focus moving into a portalled popover. Events are settled (a rAF re-check, becausedocument.activeElementis transiently<body>mid-handoff andrelatedTargetis unreliable on mobile). The React callback hooks move to the latest-ref pattern, so unmemoized callbacks no longer resubscribe on every render.iOS input auto-zoom (cherry-picked from #2982). Safari zooms the page when an input with a computed font-size below 16px takes focus, and that zoom perturbs the visual viewport the mobile toolbar positions itself from.
Selected link URL. Reading the URL from a single boundary position is fragile twice over:
marks()excludes a link at its left edge, and engines disagree by one position on where a selection over a link starts. Now scans the selected range for the first link mark, which fixes the link popover opening empty for a fully selected link on WebKit.Test infrastructure
Android-emulated browser instance. A fourth e2e instance (Android UA, touch, phone viewport) so
isTouchDevice()is genuinely true and prosemirror takes its Android code paths — no stubs. It runs the newend-to-end/mobile/suites plus the behavioral suites where Android genuinely differs. Note the per-instancecontextOptionsare silently ignored by vitest; the instance needs its ownprovider: playwright({ contextOptions }).Real-device suite on BrowserStack (
tests/device/,pnpm run test:device). A dependency-free WebDriver REST client, a gesture layer holding every platform quirk, and suites for the toolbar lifecycle and basic editing including soft-keyboard Enter. Self-skips without credentials, so it's safe to invoke anywhere. Nightly workflow included (needsBROWSERSTACK_*secrets).Verification
Every fix has an e2e test proven to fail without it — each was verified by reverting the fix and confirming the test reports the original symptom. Android instance: 45 passed, 4 expected skips. Also verified on real devices (Samsung Galaxy S22, iPhone 16e) for the Enter and popover fixes.
The iOS auto-zoom fix is covered at two levels, since the rule and the behaviour it prevents are testable in different places: the emulated suite asserts the input's computed font-size stays at/above 16px (the CSS contract), and the real-device suite asserts
visualViewport.scaledoesn't increase when the URL input takes focus (the actual behaviour).Draft: a follow-up branch stacks ~27 more commits on top of this one (comments/toolbar handoff, IME composition coverage, visual baselines, accessibility scanning, a tablet device target).